Skip to content

feat: read-only shared map viewer with password gate - #469

Merged
joaquimds merged 6 commits into
mainfrom
feat/read-only-map-viewer
Aug 5, 2026
Merged

feat: read-only shared map viewer with password gate#469
joaquimds merged 6 commits into
mainfrom
feat/read-only-map-viewer

Conversation

@joaquimds

@joaquimds joaquimds commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

The frontend for read-only private map sharing — stages 4–5 of the plan in READ_ONLY_PRIVATE_MAPS.md, building on the backend merged in #468. Adds the /share/[token] viewer route and the password gate. (Stage 6 — the share dialog, feature flag, and toggle rename — follows in a final PR; until it lands, shares can only be created via the mapShare tRPC router.)

The viewer (/share/[token])

A chrome-free version of the private map view for anonymous recipients:

  • Server component resolves the token → notFound() if missing/disabled → checks the grant cookie → renders the map shell, the password form, or redirects through the claim endpoint. The map is fetched server-side (the grant cookie authorises the tRPC caller) and seeded into the React Query cache, same pattern as the public map page. robots: noindex on the route.
  • What recipients see: boundary hover info and the inspector (top), display-only choropleth legend + marker legend + map style selector (bottom-left), zoom (bottom-right), timeline (bottom-centre), and a slim navbar with the map name, a view switcher, and area search. No control panels, no table, no draw/pin modes.
  • View switching works across all the map's views (client-side, URL kept shareable via ?viewId=), matching the whole-map share scope.

Grant minting (who sets the cookie, and when)

  • Passwordless: the page renders ShareClaim, which POSTs to /api/share/[token]/claim (mints the grant cookie, returns 204 — exists because Next.js can't set cookies during page render) and then refreshes, after which the server component sees the grant — the same mechanism as the password flow, minus the form. The URL never changes. Blocked cookies are detected without URL state (a sessionStorage timestamp catches remount loops; a finished refresh still rendering ShareClaim means the cookie didn't stick) and show a friendly message instead of looping.
  • Password-protected: the page renders SharePasswordForm; POST /api/share/[token]/verify checks scrypt, rate-limited 5 attempts / 15 min per IP + token via the existing Redis limiter, and mints on success. The form then refreshes, and the server component sees the grant.

Keeping read-only actually read-only

The private map tree assumes an editing user in several places; each is handled:

  • ReadOnlyNavbar exists because the private navbar writes on mount (auto thumbnail upload, initial-view creation) — neither is mounted.
  • useMapViews now skips map.updateViews/mapView.delete on the read-only route: map-style and timeline changes still update the query cache (instant feedback) but are never persisted and reset on reload.
  • The inspector hides its config gears, "Add to areas", and "View in table" via a new useMapEditable() hook (backed by isReadOnlyRouteAtom, following the isPublicMapRouteAtom precedent). The Notes tab already self-gates on org membership.
  • useDataSources uses dataSource.listForMapView (grant-authorised) instead of the protectedProcedure listReadable on anonymous viewer routes.
  • LegendDisplay is a new display-only choropleth legend (bars + labels, reusing LegendBars/BivariateLegend); the 790-line editor Legend is untouched. MarkerLegend was already write-free and is reused as-is.
  • The markers streaming API now honours the marker-styling properties param for grant holders whose share covers the data source — so shared maps look exactly like the editor, while public-map/public-data-source anonymous requests stay on minimal properties.

Test plan

  • All 40 existing share tests still pass (repository, router, access control — including the password-change invalidation lifecycle that drives the mid-session re-prompt)
  • npm run lint clean (prettier, eslint, tsc, madge)
  • Manual (stage 4 checkpoint): enable a passwordless share via mapShare.enable, open the link logged out — map renders read-only with legend/hover/inspector/zoom/style/timeline/search; view switching works; no write requests in the network tab
  • Manual (stage 5 checkpoint): set a password, open in incognito — form shows, wrong password rejected and rate-limited, correct password shows the map; changing the password boots an open session back to the form on next load

Notes for reviewers

  • The claim/verify route handlers are deliberately untested at the unit level (they're thin wrappers over cookies() + already-tested primitives); the manual checkpoints above cover them.
  • A viewer whose grant goes stale mid-session (password changed) sees data requests start failing until their next page load, which re-prompts. A proactive client-side bounce on 401 is a possible polish item, noted in the plan.
  • Share viewers are desktop-only for now (same small-screen message as the private editor).

🤖 Generated with Claude Code

joaquimds and others added 4 commits August 5, 2026 15:46
Stages 4-5 of the read-only private maps feature (plan in
READ_ONLY_PRIVATE_MAPS.md):

- /share/[token] page: resolves the share, mints the grant cookie via
  a claim route handler for passwordless shares (cookies cannot be set
  during page render), renders the password form for protected ones,
  and seeds the map query cache server-side
- Password gate: SharePasswordForm + POST /api/share/[token]/verify
  with scrypt verification and per-IP+token Redis rate limiting
- Read-only shell: ReadOnlyNavbar (name, view switcher, area search —
  no thumbnail upload or initial-view writes), ReadOnlyMapControls
  (boundary hover info, inspector, style/zoom/timeline), LegendDisplay
  (display-only choropleth legend) + existing MarkerLegend
- isReadOnlyRouteAtom + useMapEditable(); inspector hides its config
  gears, "Add to areas" and "View in table" in read-only mode
- useMapViews skips server writes on the read-only route so map style
  and timeline changes stay client-side only
- useDataSources uses listForMapView for anonymous viewer routes
- Markers API honours the properties param for grant holders whose
  share covers the data source, so marker styling matches the editor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The passwordless claim flow was page -> 307 claim -> 307 page?c=1, with
the c=1 marker guarding against redirect loops when cookies are blocked.
The marker leaked into the address bar, so a viewer copying the URL to
share it passed on a link that showed the cookies-required message to
new visitors without ever attempting a claim.

The claim endpoint is now a POST returning 204 (mirroring verify), and
the page renders a ShareClaim component that fetches it and refreshes -
the same mechanism as SharePasswordForm, minus the form. The URL never
changes. Blocked cookies are detected without URL state: a sessionStorage
timestamp catches remount loops, and a finished refresh that still
renders ShareClaim means the cookie did not stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReadOnlyNavbar sits inside the share page's pointer-events-none overlay
and never restored pointer events, so the view switcher and search box
were click-transparent. Wrap it in pointer-events-auto, as MapNavbar
does inside the same overlay pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joaquimds
joaquimds force-pushed the feat/read-only-map-viewer branch from 4f678a4 to d98af83 Compare August 5, 2026 17:58
joaquimds and others added 2 commits August 5, 2026 20:05
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…im guard

- Scope ShareClaim's sessionStorage claim-attempt key per token so a
  claim on one share cannot flag a different share's link as blocked
- Detect blocked cookies in SharePasswordForm the same way ShareClaim
  does (refresh inside a transition; surviving a finished refresh means
  the grant cookie did not stick) instead of silently re-showing the form
- Treat a 404 from verify (share disabled / token rotated) as a refresh
  to the real 404 page rather than reporting an incorrect password

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joaquimds
joaquimds merged commit 1779a8c into main Aug 5, 2026
1 check passed
@joaquimds
joaquimds deleted the feat/read-only-map-viewer branch August 5, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant